﻿/*
 * HyperStat.NET - The minimalist's static website compiler.
 * Copyright (C) 2012 Benjamin Brent
 * http://www.hyperstat.net/
 * http://hyperstat.codeplex.com/
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Xml;
using HtmlAgilityPack;
using System.Collections;
using MarkdownSharp;
using System.Text.RegularExpressions;
using dotless.Core;
using Yahoo.Yui.Compressor;
using System.Diagnostics;
using dotless.Core.configuration;
using dotless.Core.Loggers;
using Jurassic;

namespace HyperStat
{
    /// <summary>
    /// Generates a static HTML site from the given project directory.
    /// </summary>
    class StaticHtmlGenerator
    {
        // VERSION STRING
        // TODO: Keep updated.
        public const string HS_VERSION = "Alpha v0.2";

        // Some strings used throughout the program
        public const string NAVIGATION_FILE = "navigation.tpl";
        public const string MASTER_TPL_FILE = "master.tpl";
        public const string PROJ_SETTINGS_FILE = "settings.hyst";
        public const string CHANGES_FILE = ".changes.hyst";
        public const string KNOWN_FILES = ".known.hyst";
        public const string DEFAULT_OUT_DIR = "out";
        public const string CONTENT_DIR = "content";
        public const string CSS_DIR = "css";
        public const string JS_DIR =  "js";
        public const string STYLE_DIR = "style";
        public const string IMG_DIR = "img";
        public const string TEMP_WEBROOT = "HyperServe.tmp";
        public const string INCLUDE_REGEXP = @"{{c:include:(.*?)}}";

        // The number of iterations an infinite content loop will be allowed to perform
        private const int NUM_ALLOWABLE_INCLUDE_LOOPS = 50;

        private bool overwrite = false;
        private DirectoryInfo projectDir = null;
        private DirectoryInfo outputDir = null;
        private HyperStatSettings settings = null;

        // Files to /always/ watch and recompile the entire project if changed
        private Dictionary<string, string> majorFileChanges = new Dictionary<string, string> {
                                                                     {MASTER_TPL_FILE, null},
                                                                     {NAVIGATION_FILE, null},
                                                                     {PROJ_SETTINGS_FILE, null},
                                                                 };

        // Misc files to watch for changes (JS/CSS/IMG/CoffeeScript/Stylus/[COPY])
        private List<string> miscFileChanges = new List<string>();

        // Content includes to be monitored
        private List<string> monitoredIncludes = new List<string>();

        // Contains processed paths to avoid processing them for every page
        private List<string> processedFiles = new List<string>();

        // Protocols to be ignored in content detection
        private static readonly string[] ignoredProtocols = {
                                                      "http:",
                                                      "https:",
                                                      "ftp:",
                                                      "sftp:",
                                                      "file:",
                                                      ".",
                                                      "/",
                                                      @"\"
                                                  };

        // Output files generated in the site compilation process
        private List<string> knownOutputFiles = new List<string>();

        private Dictionary<string, string> contentIncludeCache = new Dictionary<string, string>();

        private ScriptEngine csEngine = null;
        private ScriptEngine stylEngine = null;

        private Markdown markdown;

        #region Properties and Constructors

        /// <summary>
        /// Sets the static HTML project directory
        /// </summary>
        public string ProjectDir
        {
            get
            {
                if (projectDir != null)
                    return projectDir.FullName;
                else return null;
            }
            set
            {
                value = Util.Unslashify(value.Trim()).TrimEnd('\"');
                projectDir = new DirectoryInfo(value);
                if (!projectDir.Exists)
                    throw new HyperStatException("The specified project directory was not found!");
            }
        }
        
        /// <summary>
        /// Sets the HTML output directory
        /// </summary>
        public string OutputDir
        {
            get { return outputDir.FullName; }
            set
            {
                value = Util.Unslashify(value.Trim()).TrimEnd('\"');
                Directory.CreateDirectory(value);
                outputDir = new DirectoryInfo(value);
            }
        }

        /// <summary>
        /// Sets whether output files should be overwritten or not.
        /// </summary>
        public bool Overwrite
        {
            get { return overwrite; }
            set { overwrite = value; }
        }

        #endregion

        /// <summary>
        /// Checks that the required project files exist.
        /// </summary>
        private void CheckProject()
        {
            if (!File.Exists(Path.Combine(ProjectDir, PROJ_SETTINGS_FILE)) ||
                !File.Exists(Path.Combine(ProjectDir, MASTER_TPL_FILE)) ||
                !File.Exists(Path.Combine(ProjectDir, NAVIGATION_FILE)))
                throw new HyperStatException("Check that the project directory is configured correctly and that no files are missing.");
            settings = ReadSettings(Path.Combine(ProjectDir, PROJ_SETTINGS_FILE));

            foreach (string ctnt in settings.Content.Keys)
            {
                string path = settings.MakeCtntInPath(ctnt);
                if (!File.Exists(path))
                {
                    string filename = Path.GetFileNameWithoutExtension(path);
                    throw new HyperStatException("The declared content file " + filename + " does not exist in the content directory.");
                }
            }

            if (markdown == null)
            {
                MarkdownOptions options = new MarkdownOptions()
                {
                    AutoHyperlink = true,
                    Prettify = settings.Prettify,
                };
                markdown = new Markdown(options);
            }
        }

        /// <summary>
        /// Reads the project's settings file.
        /// </summary>
        private HyperStatSettings ReadSettings(string settingsFilePath)
        {
            return new HyperStatSettings(projectDir.FullName, settingsFilePath);
        }

        /// <summary>
        /// Attempts to read the changes file for the currently loaded project. Returns null on error.
        /// </summary>
        /// <returns>Null on error, list of content file names of changes on success.</returns>
        private List<string> ReadChanges()
        {
            try
            {
                // Path to changes file
                string chFilePath = Path.Combine(ProjectDir, CHANGES_FILE);
                
                // Check that file exists
                if (!File.Exists(chFilePath))
                    return null;

                // Map out and find changed includes
                List<string> changedIncludes = new List<string>();
                Dictionary<string, List<string>> contentIncludes = new Dictionary<string, List<string>>();

                // Try to read and validate file
                string[] lines = File.ReadAllLines(chFilePath);
                List<string> output = new List<string>();

                // Parse each file's last data
                foreach (string line in lines)
                {
                    bool isInc = false;
                    string[] parts = Regex.Split(line, "::");
                    string filePath;
                    if (parts[0].StartsWith("__"))
                    {
                        parts[0] = parts[0].Replace("_", string.Empty).ToLower();
                        filePath = Path.Combine(ProjectDir, parts[0]);
                    }
                    else if (parts[0].StartsWith("#MISC:"))
                    {
                        filePath = Path.Combine(ProjectDir, Util.Pathify(parts[0].Replace("#MISC:", string.Empty)));
                    }
                    else if (parts[0].StartsWith("#INC:"))
                    {
                        isInc = true;
                        filePath = settings.MakeCtntInPath(Util.Pathify(parts[0].Replace("#INC:", string.Empty)));
                    }
                    else
                    {
                        filePath = settings.MakeCtntInPath(parts[0]);
                    }

                    FileInfo file = new FileInfo(filePath);

                    if (!file.Exists)
                        continue; // Missing file, ignore...

                    // File has includes, make sure we know about them
                    if(parts.Length >= 3)
                        contentIncludes.Add(parts[0], parts[2].Split(',').ToList());

                    // If the file's last write time is different, add it to the output list
                    if (file.LastWriteTimeUtc.CompareTo(DateTime.FromBinary(long.Parse(parts[1]))) != 0)
                    {
                        if(!isInc)
                            output.Add(parts[0]); // we don't need to add includes here, only the pages that use them
                        else
                            changedIncludes.Add(parts[0].Replace("#INC:", string.Empty)); // this was a changed include file
                    }
                }

                // Add all pages which use changed includes to output
                foreach (string changedInclude in changedIncludes)
                {
                    foreach (string content in contentIncludes.Keys)
                    {
                        if (contentIncludes[content].Contains(changedInclude) && 
                            !output.Contains(content))
                            output.Add(content);
                    }
                }

                return output;
            }
            catch (Exception)
            {
                // Some error occurred, fail.
                return null;
            }
        }

        /// <summary>
        /// Writes the project files and last write times to the changes file in the project directory.
        /// </summary>
        /// <param name="filepaths">List of absolute paths of files to check.</param>
        private void WriteChanges(Dictionary<string, string> ctntKeys, List<string> miscFiles, List<string> includes)
        {
            try
            {
                // Path to changes file
                string chFilePath = Path.Combine(ProjectDir, CHANGES_FILE);

                // Output list
                List<string> linesWrite = new List<string>();

                // Loop all monitored project files
                foreach (string ctntKey in ctntKeys.Keys)
                {
                    string ctntPath = settings.MakeCtntInPath(ctntKey);
                    FileInfo file = new FileInfo(ctntPath);
                    if (!file.Exists)
                        throw new HyperStatException("Missing file: " + ctntPath); // Missing file... fail

                    // Build output and add to lines list
                    string metadata = ctntKeys[ctntKey];
                    linesWrite.Add(String.Format("{0}::{1}::{2}", ctntKey, file.LastWriteTimeUtc.ToBinary().ToString(), metadata));
                }

                // Track major files
                foreach (string majorFile in majorFileChanges.Keys)
                {
                    string fPath = Path.Combine(ProjectDir, majorFile);
                    FileInfo fInfo = new FileInfo(fPath);
                    if (!fInfo.Exists)
                        throw new HyperStatException("Missing file: " + majorFile);
                    string metadata = majorFileChanges[majorFile];
                    linesWrite.Add(String.Format("__{0}__::{1}::{2}", majorFile.ToUpper(), fInfo.LastWriteTimeUtc.ToBinary().ToString(), metadata));
                }

                // Track misc files
                foreach (string miscFile in miscFiles)
                {
                    string fPath = Path.Combine(ProjectDir, Util.Pathify(miscFile));
                    FileInfo fInfo = new FileInfo(fPath);
                    if (!fInfo.Exists)
                        throw new HyperStatException("Missing file: " + miscFile);
                    linesWrite.Add(String.Format("#MISC:{0}::{1}", miscFile.Replace(@"\", "/"), fInfo.LastWriteTimeUtc.ToBinary().ToString()));
                }

                // Track content includes
                foreach (string include in includes)
                {
                    string fPath = settings.MakeCtntInPath(include);
                    FileInfo fInfo = new FileInfo(fPath);
                    if (!fInfo.Exists)
                        throw new HyperStatException("Missing file: " + include);
                    linesWrite.Add(String.Format("#INC:{0}::{1}", include, fInfo.LastWriteTimeUtc.ToBinary().ToString()));
                }

                // Write changes file
                File.Delete(chFilePath); // ensure that we leave no trace of the old (issue #r95)
                File.WriteAllLines(chFilePath, linesWrite.ToArray());
            }
            catch (HyperStatException ex)
            {
                Warning(ex.Message);
            }
            catch (Exception)
            {
                // Some error occurred, exit...
                return;
            }
        }

        /// <summary>
        /// Generates a static HTML website from the specified project directory.
        /// </summary>
        /// <param name="changesOnly">True to generate only changed dirs, false to generate entire tree.</param>
        public void Generate(bool changesOnly = false, string singlePage = null)
        {
            // Initialise the generator
            Console.WriteLine("Initializing...");
            CheckProject();

            // Run the pre-compile hooks
            Console.WriteLine("Running pre-compile hooks...");
            RunHooks(HyperStatSettings.HookLocation.PreCompile);

            // Check for changes file
            List<string> changes = ReadChanges();
            if (changesOnly && changes == null)
            {
                // No changes file, do a normal compile
                changesOnly = false;
            }

            // Read the known files file
            List<string> oldKnownFiles = ReadKnownFiles();

            // If the changes include one of these files, compile the whole project
            if (changesOnly && changes.Count > 0)
            {
                foreach (string change in changes)
                {
                    if (majorFileChanges.Keys.Contains(change))
                    {
                        // Major file changed... recompile everything
                        changesOnly = false;
                        singlePage = null;
                        break;
                    }
                }
            }

            // If we're just compiling changes, make sure there /are/ some changes
            // to be compiled, or we could be wasting our time doing all the rest.
            if (changesOnly && changes.Count == 0)
            {
                Console.WriteLine("No changes found!");
                return;
            }
            else if (changesOnly)
            {
                Console.WriteLine("Compiling changes only...");
            }

            // Clear the content includes cache
            contentIncludeCache.Clear();

            // Perform file copies if doing a full compile of all pages
            if(!changesOnly && singlePage == null)
                CopyFiles(settings.FileCopy);

            // Copy misc monitored files
            if(changesOnly)
                CopyChangedMiscFiles(changes);

            // Read the master and navigation files
            Console.WriteLine("Reading {0}", MASTER_TPL_FILE);
            string masterPage = File.ReadAllText(Path.Combine(ProjectDir, MASTER_TPL_FILE), Encoding.UTF8);
            Console.WriteLine("Reading {0}", NAVIGATION_FILE);
            string navigationHtml = File.ReadAllText(Path.Combine(ProjectDir, NAVIGATION_FILE), Encoding.UTF8);

            // Find includes in TPL files
            majorFileChanges[MASTER_TPL_FILE] = String.Join(",", FindAllUsedIncludes(masterPage).ToArray<string>());
            majorFileChanges[NAVIGATION_FILE] = String.Join(",", FindAllUsedIncludes(navigationHtml).ToArray<string>());

            Dictionary<string, string> trackedCtnt = new Dictionary<string, string>();

            // Generate each page from the project content
            foreach(string ctnt in settings.Content.Keys)
            {
                HyperStatSettings.ContentType ctntType;
                string ctntPath = settings.MakeCtntInPath(ctnt, out ctntType);
                string pagePath = settings.MakeCtntOutPath(OutputDir, ctnt, false);
                string pageTitle = settings.Content[ctnt];

                // Read the content file
                string pageContent = File.ReadAllText(ctntPath);

                // Track this content if it isn't already tracked
                if (!trackedCtnt.Keys.Contains(ctnt))
                    trackedCtnt.Add(ctnt, String.Join(",", FindAllUsedIncludes(pageContent).ToArray<string>()));

                // Check if this content has been changed (changes only mode)
                if (changesOnly && !changes.Contains(ctnt))
                    continue; // skip if we're compiling changes only and this content hasn't been changed

                // Check if we're compiling a single page only, and if so, skip any pages that aren't our target.
                if (singlePage != null && !ctnt.Equals(singlePage, StringComparison.OrdinalIgnoreCase))
                    continue;

                Console.WriteLine("Compiling " + Path.GetFileName(ctntPath));

                // Compile navigation
                string compiledNav = CompileNav(ctnt, navigationHtml);

                // Parse Markdown into HTML
                if (ctntType == HyperStatSettings.ContentType.Markdown)
                {
                    MarkdownOptions options = new MarkdownOptions()
                    {
                        AutoHyperlink = true, 
                        Prettify = settings.Prettify,
                    };
                    pageContent = markdown.Transform(pageContent);
                }

                // Define the slots
                Dictionary<string, string> masterSlots = new Dictionary<string, string>();
                masterSlots.Add("NAVIGATION", compiledNav);
                masterSlots.Add("TITLE", pageTitle);
                masterSlots.Add("CONTENT_ID", ctnt);

                masterSlots.Add("CONTENT", pageContent); // this must always be last

                // Compile the page
                string outPage = CompilePage(ctnt, masterPage, masterSlots);

                // Add to known output
                AddKnownFile(pagePath);

                // Write page to output directory
                Directory.CreateDirectory(Path.GetDirectoryName(pagePath));
                File.WriteAllText(pagePath, outPage, Encoding.UTF8);
            }

            // Generate the changes file
            WriteChanges(trackedCtnt, this.miscFileChanges, this.monitoredIncludes);

            // Clean the output dir if we aren't compiling changes only and we're in normal mode
            if (!changesOnly && singlePage == null && !Program.serverMode)
                CleanOutputDir(knownOutputFiles, oldKnownFiles);

            // Write the known files file
            WriteKnownFiles();

            // Run the post-compile hooks
            Console.WriteLine("Running post-compile hooks...");
            RunHooks(HyperStatSettings.HookLocation.PostCompile);
        }

        /// <summary>
        /// Compiles the navigation section for the given content key.
        /// </summary>
        /// <returns>The HTML of the compiled navigation section.</returns>
        private string CompileNav(string ctntKey, string navHtml)
        {
            Console.WriteLine("    - Navigation");
            HtmlDocument navDoc = new HtmlDocument();

            // Create navigation links and text
            foreach (string ctntName in settings.Content.Keys)
            {
                string ctntDisp = settings.Content[ctntName];
                string baseUri = Path.GetDirectoryName(settings.MakeCtntOutPath(OutputDir, ctntKey, false)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
                string ctntFile = MakeRelativePath(baseUri, settings.MakeCtntOutPath(OutputDir, ctntName, false));
                
                // Insert the content links
                string cssClass = string.Empty;
                navHtml = navHtml.Replace("{{c:" + ctntName.ToUpper() + "}}", "<a id=\"_" + ctntName.ToUpper() + "\" href=\"" + ctntFile + "\">" + ctntDisp + "</a>");
            }

            string currentNavElementId = "_" + ctntKey.ToUpper();

            navDoc = new HtmlDocument();
            navDoc.LoadHtml(navHtml);
            HtmlNode currNav = navDoc.GetElementbyId(currentNavElementId);
            string navActive = settings.CssNavActive;

            // Set active page CSS class
            if (currNav != null)
            {
                HtmlNode target = GetTargetLayerNode(currNav, settings.CssNavLayer);
                if (target != null)
                {
                    if (target.Attributes.Contains("class"))
                        navActive = target.Attributes["class"] + settings.CssNavActive;
                    target.SetAttributeValue("class", navActive);
                }
            }

            return navDoc.DocumentNode.OuterHtml;
        }

        /// <summary>
        /// Compiles a page from the given master and slots.
        /// </summary>
        /// <param name="pageKey">The key of the page to be compiled.</param>
        /// <param name="masterPage">HTML string of master page.</param>
        /// <param name="slots">Slots, in the format {{SLOT_NAME, SLOT_HTML}, {SLOT2_NAME, SLOT2_HTML}}.</param>
        /// <returns>HTML of the compiled page.</returns>
        private string CompilePage(string pageKey, string masterPage, Dictionary<string, string> slots)
        {
            // Some things that remain constant
            string baseUri = Util.Slashify(Path.GetDirectoryName(settings.MakeCtntOutPath(OutputDir, pageKey, false)));

            Console.WriteLine("    - Slots");

            // Compile the slots
            foreach (string slot in slots.Keys)
                masterPage = masterPage.Replace("{{s:" + slot + "}}", slots[slot]);

            // Process content includes
            Console.WriteLine("    - Includes");
            masterPage = ProcessContentIncludes(masterPage);

            // Insert user variable values
            foreach (string varname in settings.UserVars.Keys)
                masterPage = masterPage.Replace("{{v:" + varname + "}}", settings.UserVars[varname], StringComparison.OrdinalIgnoreCase);

            Console.WriteLine("    - Links");

            // Generate internal links
            // Replace {{c:CONTENT_NAME}}       with  <a href="content_name.html">Content Title</a>
            // Replace {{c:path:_CONTENT_NAME}} with  content_name.html
            foreach (string ctntName in settings.Content.Keys)
            {
                string ctntTitle = settings.Content[ctntName];
                string ctntFile = MakeRelativePath(baseUri, settings.MakeCtntOutPath(OutputDir, ctntName, false));

                masterPage = masterPage.Replace("{{c:" + ctntName.ToUpper() + "}}", "<a href=\"" + ctntFile + "\">" + ctntTitle + "</a>");
                masterPage = masterPage.Replace("{{c:path:" + ctntName.ToUpper() + "}}", ctntFile);
            }

            // Load the HTML
            HtmlDocument pageDoc = new HtmlDocument();
            pageDoc.LoadHtml(masterPage);

            // Add lang styles for code Prettify
            if (settings.Prettify)
                AddPrettifyLangs(pageDoc);

            // Make relative CSS, IMG and JS links and copy files to output
            HtmlNodeCollection imgNodes = pageDoc.DocumentNode.SelectNodes("//img");
            HtmlNodeCollection scriptNodes = pageDoc.DocumentNode.SelectNodes("//script");
            HtmlNodeCollection linkNodes = pageDoc.DocumentNode.SelectNodes("//link");
            HtmlNodeCollection styleNodes = pageDoc.DocumentNode.SelectNodes("//style");
            HtmlNodeCollection conditionalNodes = pageDoc.DocumentNode.SelectNodes("//@cond");

            // Conditional nodes

            if (conditionalNodes != null)
            {
                foreach (HtmlNode condNode in conditionalNodes)
                {
                    if (condNode.HasAttributes)
                    {
                        string[] condParts = condNode.GetAttributeValue("cond", string.Empty).Split(':');
                        condNode.Attributes.Remove("cond");
                        if (condParts.Length < 2)
                            continue;
                        string cond = condParts[1];
                        bool logicalNot = condParts[0].StartsWith("!");
                        condParts[0] = condParts[0].TrimStart('!');

                        // Content conditionals
                        if (condParts[0] == "c")
                        {
                            if (logicalNot && cond.Equals(pageKey, StringComparison.OrdinalIgnoreCase))
                                condNode.Remove();
                            else if (!logicalNot && !cond.Equals(pageKey, StringComparison.OrdinalIgnoreCase))
                                condNode.Remove();
                        }
                    }
                }
            }

            // Images

            Console.WriteLine("    - Images");

            if (imgNodes != null)
            {
                foreach (HtmlNode imgNode in imgNodes)
                {
                    string src = imgNode.GetAttributeValue("src", null);

                    // No src found or contains non-relative link
                    if (src == null || src.StartsWith(StringComparison.OrdinalIgnoreCase, ignoredProtocols))
                        continue;

                    src = src.Trim();
                    src = src.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);

                    Console.WriteLine("     -- " + src);

                    // Create relative URI
                    string relUri = MakeRelativePath(baseUri, settings.MakeImgOutPath(OutputDir, src));
                    imgNode.SetAttributeValue("src", relUri);

                    // Monitor file changes
                    MonitorMiscFile(relUri);

                    // Copy resource
                    string fileSrc = settings.MakeImgInPath(src);
                    string fileDst = settings.MakeImgOutPath(OutputDir, src);

                    // Only process if not already processed
                    if (!processedFiles.Contains(fileDst))
                        CopyFile(fileSrc, fileDst);
                }
            }

            // JS

            Console.WriteLine("    - JavaScript");

            if (scriptNodes != null)
            {
                foreach (HtmlNode scriptNode in scriptNodes)
                {
                    string src = scriptNode.GetAttributeValue("src", null);
                    string type = scriptNode.GetAttributeValue("type", string.Empty).Trim();

                    // Inline script
                    if (src == null && 
                        scriptNode.ChildNodes.Count > 0 && 
                        scriptNode.ChildNodes[0].NodeType == HtmlNodeType.Text)
                    {
                        HtmlTextNode scriptTextNode = (HtmlTextNode) scriptNode.ChildNodes[0];

                        // Compile CoffeeScript
                        if (type.Equals("text/coffeescript", StringComparison.OrdinalIgnoreCase))
                        {
                            scriptTextNode.Text = ProcessInlineScript(scriptTextNode.Text, ScriptType.CoffeeScript);
                            scriptNode.SetAttributeValue("type", "text/javascript"); // fix attrib value
                        }

                        // Process regular JS
                        else if (type.Equals("text/javascript", StringComparison.OrdinalIgnoreCase))
                            scriptTextNode.Text = ProcessInlineScript(scriptTextNode.Text, ScriptType.JavaScript);
                    }

                    // No src or contains non-relative link
                    else if (src == null || src.StartsWith(StringComparison.OrdinalIgnoreCase, ignoredProtocols))
                    {
                        continue;
                    }

                    else
                    {
                        src = src.Trim();
                        src = src.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);

                        Console.WriteLine("     -- " + src);

                        // Create relative URI
                        string relUriOut = MakeRelativePath(baseUri, settings.MakeJsOutPath(OutputDir, src));
                        string relUriOrigExt = MakeRelativePath(baseUri, settings.MakeJsOutPath(OutputDir, src, true));
                        scriptNode.SetAttributeValue("src", relUriOut);

                        // Monitor file changes
                        MonitorMiscFile(relUriOrigExt);

                        // Copy resource
                        string fileSrc = settings.MakeJsInPath(src);
                        string fileDst = settings.MakeJsOutPath(OutputDir, src);

                        // Only process if not already processed
                        if (!processedFiles.Contains(fileDst))
                        {
                            // Copy the resource
                            CopyFile(fileSrc, fileDst);

                            // Determine script type and process
                            if (Util.GetExtension(fileSrc).ToLower() == "coffee" ||
                                type.Equals("text/coffeescript", StringComparison.OrdinalIgnoreCase))
                            {
                                scriptNode.SetAttributeValue("type", "text/javascript");
                                ProcessScriptFile(fileDst, ScriptType.CoffeeScript);
                            }
                            else ProcessScriptFile(fileDst, ScriptType.JavaScript);

                            processedFiles.Add(fileDst);
                        }
                    }
                }
            }

            // Inline CSS

            Console.WriteLine("    - Inline styles");

            if (styleNodes != null)
            {
                foreach (HtmlNode styleNode in styleNodes)
                {
                    // Empty
                    if (!styleNode.HasChildNodes || styleNode.ChildNodes.Count < 1 || styleNode.ChildNodes[0].NodeType != HtmlNodeType.Text)
                        continue;

                    HtmlTextNode styleTextNode = (HtmlTextNode) styleNode.ChildNodes[0];
                    string type = styleNode.GetAttributeValue("type", string.Empty).Trim();
                    string styleCode = styleTextNode.Text;

                    // Empty
                    if (styleCode.Length < 2)
                        continue;

                    // Process LESS
                    if(type.Equals("text/less", StringComparison.OrdinalIgnoreCase))
                        styleCode = ProcessInlineStyle(styleCode, CssType.LESS);

                    // Process Stylus
                    else if(type.Equals("text/stylus", StringComparison.OrdinalIgnoreCase))
                        styleCode = ProcessInlineStyle(styleCode, CssType.Stylus);

                    // Process Regular CSS
                    else
                        styleCode = ProcessInlineStyle(styleCode, CssType.CSS);

                    styleTextNode.Text = styleCode;
                }
            }

            // CSS and favicons

            Console.WriteLine("    - CSS and favicons");

            if (linkNodes != null)
            {
                foreach (HtmlNode linkNode in linkNodes)
                {
                    string href = linkNode.GetAttributeValue("href", null);
                    string rel = linkNode.GetAttributeValue("rel", null);
                    string type = linkNode.GetAttributeValue("type", string.Empty).Trim();

                    // No href found or contains non-relative link
                    if (href == null || 
                        rel == null || 
                        href.StartsWith(StringComparison.OrdinalIgnoreCase, ignoredProtocols))
                        continue;

                    rel = rel.Trim();
                    href = href.Trim();
                    href = href.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);

                    // CSS
                    if (rel.Equals("stylesheet", StringComparison.OrdinalIgnoreCase))
                    {
                        Console.WriteLine("     -- " + href);

                        // Create relative URI
                        string relUriOut = MakeRelativePath(baseUri, settings.MakeCssOutPath(OutputDir, href));
                        string relUriOrigExt = MakeRelativePath(baseUri, settings.MakeCssOutPath(OutputDir, href, true));
                        linkNode.SetAttributeValue("href", relUriOut);

                        // Monitor file changes
                        MonitorMiscFile(relUriOrigExt);

                        string fileSrc = settings.MakeCssInPath(href);
                        string fileDst = settings.MakeCssOutPath(OutputDir, href);

                        // Copy CSS resources and make relative url()s
                        if (!processedFiles.Contains(fileDst))
                        {
                            // Fix any wrong type attribs
                            if (!type.Equals("text/css", StringComparison.OrdinalIgnoreCase))
                                linkNode.SetAttributeValue("type", "text/css");

                            // Copy resource
                            CopyFile(fileSrc, fileDst);

                            // Determine CSS type and process
                            string extL = Util.GetExtension(fileSrc).ToLower();
                            if (extL == "less" ||
                                type.Equals("text/less", StringComparison.OrdinalIgnoreCase))
                                ProcessCssFile(fileDst, CssType.LESS);
                            else if (extL == "styl" ||
                                type.Equals("text/stylus", StringComparison.OrdinalIgnoreCase))
                                ProcessCssFile(fileDst, CssType.Stylus);
                            else
                                ProcessCssFile(fileDst, CssType.CSS);
                            
                            processedFiles.Add(fileDst);
                        }
                    }

                    // Favicon
                    else if (rel.Equals("shortcut icon", StringComparison.OrdinalIgnoreCase) ||
                             rel.Equals("icon", StringComparison.OrdinalIgnoreCase))
                    {
                        Console.WriteLine("     -- " + href);

                        // Create relative URI
                        string relUri = MakeRelativePath(baseUri, settings.MakeImgOutPath(OutputDir, href));
                        linkNode.SetAttributeValue("href", relUri);

                        // Monitor file changes
                        MonitorMiscFile(relUri);

                        // Copy resource
                        string fileSrc = settings.MakeImgInPath(href);
                        string fileDst = settings.MakeImgOutPath(OutputDir, href);

                        // Only process if not already processed
                        if (!processedFiles.Contains(fileDst))
                            CopyFile(fileSrc, fileDst);
                    }

                    // Neither, ignore
                    else
                    {
                        continue;
                    }
                }
            }

            // HTML compression
            if (settings.HtmlCompress)
                return YUICompressor.Compress(pageDoc.DocumentNode.OuterHtml, int.MaxValue, false);
            else return pageDoc.DocumentNode.OuterHtml;
        }

        /// <summary>
        /// Monitors the specified misc file.
        /// </summary>
        private void MonitorMiscFile(string relUri)
        {
            if (!miscFileChanges.Contains(relUri))
                miscFileChanges.Add(relUri);
        }

        /// <summary>
        /// Copies misc file changes
        /// </summary>
        /// <param name="changes">Changes list from ReadChanges()</param>
        private void CopyChangedMiscFiles(List<string> changes)
        {
            if (changes != null && changes.Count > 0)
            {
                foreach (string change in changes)
                {
                    if (!change.StartsWith("#MISC:"))
                        continue; // not a misc change
                    string relPath = Util.Pathify(change.Replace("#MISC:", string.Empty));
                    string fPath = Path.Combine(ProjectDir, relPath);
                    string fOutPath = Path.Combine(OutputDir, relPath);
                    HandleMiscFileChange(fPath, fOutPath);
                    Console.WriteLine("    - {0}", relPath);
                }
            }
        }

        /// <summary>
        /// Handles misc file changes. Copies any files that don't need special handling, otherwise processes the files appropriately.
        /// </summary>
        /// <param name="fPath">The file's full path (source).</param>
        /// <param name="fOutPath">The file's full output path (dest).</param>
        private void HandleMiscFileChange(string fPath, string fOutPath)
        {
            switch(Util.GetExtension(fPath).ToLower())
            {
                // Handle LESS files
                case "less":
                    fOutPath = Util.ChangeExtension(fOutPath, "css");
                    CopyFile(fPath, fOutPath, true);
                    ProcessCssFile(fOutPath, CssType.LESS);
                    break;

                // Handle Stylus files
                case "styl":
                    fOutPath = Util.ChangeExtension(fOutPath, "css");
                    CopyFile(fPath, fOutPath, true);
                    ProcessCssFile(fOutPath, CssType.Stylus);
                    break;

                // Handle regular CSS files
                case "css":
                    CopyFile(fPath, fOutPath, true);
                    ProcessCssFile(fOutPath, CssType.CSS);
                    break;

                // Handle CoffeeScript files
                case "coffee":
                    fOutPath = Util.ChangeExtension(fOutPath, "js");
                    CopyFile(fPath, fOutPath, true);
                    ProcessScriptFile(fOutPath, ScriptType.CoffeeScript);
                    break;

                // Handle regular JavaScript files
                case "js":
                    CopyFile(fPath, fOutPath, true);
                    ProcessScriptFile(fOutPath, ScriptType.JavaScript);
                    break;

                // Just blindly copy all other files
                default:
                    CopyFile(fPath, fOutPath, true);
                    break;
            }
        }

        /// <summary>
        /// Recursively finds the target 'layer' (HtmlNode) from the given HtmlNode.
        /// Negative values go inward, positive values go outward, zero doesn't move.
        /// </summary>
        /// <param name="startNode">The start position HTML node.</param>
        /// <param name="depth">The depth (-depth to go in, +depth to go out).</param>
        /// <returns>The target HtmlNode.</returns>
        private HtmlNode GetTargetLayerNode(HtmlNode startNode, int depth)
        {
            // Not going anywhere
            if (depth == 0 || startNode == null)
                return startNode;

            // Going out
            else if (depth > 0)
                return GetTargetLayerNode(startNode.ParentNode, --depth);

            // Going in
            else if (depth < 0 && startNode.ChildNodes.Count > 0 && startNode.ChildNodes[0].NodeType == HtmlNodeType.Element)
                return GetTargetLayerNode(startNode.ChildNodes[0], ++depth);

            return null;
        }

        /// <summary>
        /// Processes the given CSS file (makes relative url()s and copies resources across).
        /// </summary>
        /// <param name="fullPath">The path to the CSS file to be processed.</param>
        private void ProcessCssFile(string fullPath, CssType cssType)
        {
            try
            {
                bool _fileChangedFlag = false;

                if (!File.Exists(fullPath))
                    throw new HyperStatException();

                string[] cssLines = File.ReadAllLines(fullPath);

                // Compile CSS if it's LESS
                if (cssType == CssType.LESS)
                {
                    string oldCD = Directory.GetCurrentDirectory();
                    Directory.SetCurrentDirectory(Util.Slashify(Path.GetDirectoryName(settings.MakeCssInPath("example.css"))));
                    //--------------------------------------------------------------------------------
                    DotlessConfiguration dotlessconf = new DotlessConfiguration(){Logger = (new LessLogger(LogLevel.Warn, this)).GetType()};
                    cssLines = Regex.Split(Less.Parse(String.Join("\r\n", cssLines), dotlessconf), "\r\n|\r|\n");
                    //--------------------------------------------------------------------------------
                    Directory.SetCurrentDirectory(oldCD);

                    _fileChangedFlag = true;
                }

                // Compile Stylus
                else if (cssType == CssType.Stylus)
                {
                    string oldCD = Directory.GetCurrentDirectory();
                    Directory.SetCurrentDirectory(Util.Slashify(Path.GetDirectoryName(settings.MakeCssInPath("example.css"))));
                    //--------------------------------------------------------------------------------
                    cssLines = Regex.Split(CompileStylus(String.Join("\r\n", cssLines)), "\r\n|\r|\n");
                    //--------------------------------------------------------------------------------
                    Directory.SetCurrentDirectory(oldCD);

                    _fileChangedFlag = true;
                }

                // Process CSS url()s
                bool _cssChangedFlag;
                cssLines = ProcessCssUrls(cssLines, out _cssChangedFlag);

                // Compress the CSS if enabled
                if (settings.CssCompress)
                {
                    _fileChangedFlag = true;
                    cssLines = Regex.Split(CssCompressor.Compress(String.Join("\r\n", cssLines), int.MaxValue, CssCompressionType.Hybrid, true), "\r\n|\r|\n");
                }

                // Write the new CSS file, if we've made any changes
                if (_fileChangedFlag || _cssChangedFlag)
                    File.WriteAllLines(fullPath, cssLines);
            }
            catch (FileNotFoundException ex)
            {
                Warning("WARN: {1}", ex.Message);
            }
            catch (HyperStatException)
            {
                Warning("The file `{0}` was not found", fullPath);
            }
            catch (Exception)
            {
                Warning("The file `{0}` could not be processed", fullPath);
            }
        }

        /// <summary>
        /// Processes an inline script (script HTML tag).
        /// </summary>
        /// <param name="code">The inline script code.</param>
        /// <param name="type">The inline script type.</param>
        /// <returns>The processed script code.</returns>
        private string ProcessInlineScript(string code, ScriptType type)
        {
            try
            {
                // Compile script if it's CoffeeScript
                if (type == ScriptType.CoffeeScript)
                    code = CompileCoffeeScript(code);

                // Compress the script, if enabled
                if (settings.JsCompress)
                    code = JavaScriptCompressor.Compress(code, settings.Verbose, false, false, false, int.MaxValue);

                return code;
            }
            catch (Jurassic.JavaScriptException ex)
            {
                Warning("Inline Script Compile Error: {0}", ex.Message);
                return code;
            }
            catch (Exception)
            {
                Warning("There was a problem while compiling an inline script.");
                return code;
            }
        }

        /// <summary>
        /// Processes an inline script (script HTML tag).
        /// </summary>
        /// <param name="code">The inline script code.</param>
        /// <param name="type">The inline script type.</param>
        /// <param name="baseUri">The base slashified URI to use for CSS url() processing.</param>
        /// <returns>The processed script code.</returns>
        private string ProcessInlineStyle(string style, CssType type)
        {
            try
            {
                // Compile LESS
                if (type == CssType.LESS)
                    style = Less.Parse(style, new DotlessConfiguration() { Logger = (new LessLogger(LogLevel.Warn, this)).GetType() });

                // Compile Stylus
                else if (type == CssType.Stylus)
                    style = CompileStylus(style);

                // Parse url()s
                bool cssChangedFlag;
                style = ProcessCssUrls(style, out cssChangedFlag);

                // Compress the CSS, if enabled
                if (settings.JsCompress)
                    style = CssCompressor.Compress(style, int.MaxValue, CssCompressionType.Hybrid, true);

                return style;
            }
            catch (Jurassic.JavaScriptException ex)
            {
                Warning("Inline CSS Compile Error: {0}", ex.Message);
                return style;
            }
            catch (NotImplementedException ex)
            {
                Warning(ex.Message);
                return style;
            }
            catch (Exception)
            {
                Warning("There was a problem while compiling inline CSS.");
                return style;
            }
        }

        /// <summary>
        /// For each url() in the given CSS code, the resource is located and copied, and the path is made relative to the given base.
        /// </summary>
        /// <param name="cssLines">The CSS code to be processed.</param>
        /// <returns>The processed CSS code.</returns>
        private string[] ProcessCssUrls(string[] cssLines, out bool _fileChangedFlag)
        {
            _fileChangedFlag = false;

            Regex cssUrl = new Regex(@"url\s*\((.*?)\)", RegexOptions.IgnoreCase);

            // For each CSS line
            for (int i = 0; i < cssLines.Length; i++)
            {
                // Find the url()s
                MatchCollection matches = cssUrl.Matches(cssLines[i]);

                // For each url() match
                foreach (Match match in matches)
                {
                    // Find the contents of the url()
                    if (match.Groups.Count < 2)
                        continue; // empty url()
                    string resPath = match.Groups[1].Value.Trim();

                    // Clean the contents
                    string resPathClean = Regex.Replace(resPath, "\"|'", string.Empty);

                    // Ignore if path is relative or HTTP/FTP
                    if (resPathClean.StartsWith(StringComparison.OrdinalIgnoreCase, ignoredProtocols))
                        continue;

                    // Create the various paths
                    string styleInPath = settings.MakeStyleInPath(resPathClean);
                    string styleOutPath = settings.MakeStyleOutPath(OutputDir, resPathClean);
                    string outCssDirPath = Util.Slashify(Path.GetDirectoryName(settings.MakeCssOutPath(OutputDir, "example.css")));
                    string newCssPath = MakeRelativePath(outCssDirPath, styleOutPath);

                    // Replace the url() with the new one
                    string replace = String.Format("url(\"{0}\")", newCssPath);
                    cssLines[i] = cssLines[i].Replace(match.Value, replace);

                    // Copy and monitor the resource
                    CopyFile(styleInPath, styleOutPath);
                    MonitorMiscFile(Util.Pathify(MakeRelativePath(Util.Slashify(ProjectDir), styleInPath)));

                    _fileChangedFlag = true;
                }
            }

            return cssLines;
        }

        /// <summary>
        /// For each url() in the given CSS code, the resource is located and copied, and the path is made relative to the given base.
        /// </summary>
        /// <param name="css">The CSS code to be processed.</param>
        /// <param name="baseUri">The base URI of the output file containing the CSS (slashified), defaults to output dir.</param>
        /// <returns>The processed CSS code.</returns>
        private string ProcessCssUrls(string css, out bool _fileChangedFlag)
        {
            string[] cssLines = Regex.Split(css, "\r\n|\r|\n");
            return String.Join("\r\n", ProcessCssUrls(cssLines, out _fileChangedFlag));
        }

        /// <summary>
        /// Processes the given script file (complify CoffeeScript, etc).
        /// </summary>
        /// <param name="fullPath">The path to the CSS file to be processed.</param>
        private void ProcessScriptFile(string fullPath, ScriptType type)
        {
            try
            {
                bool _fileChangedFlag = false;

                if (!File.Exists(fullPath))
                    throw new HyperStatException();

                string jsCode = File.ReadAllText(fullPath);

                // Compile script if it's CoffeeScript
                if (type == ScriptType.CoffeeScript)
                {
                    jsCode = CompileCoffeeScript(jsCode);
                    _fileChangedFlag = true;
                }

                // Compress the script, if enabled
                if (settings.JsCompress)
                {
                    _fileChangedFlag = true;
                    jsCode = JavaScriptCompressor.Compress(jsCode, settings.Verbose, false, false, false, int.MaxValue);
                }

                // Write the new script file, if it was changed
                if (_fileChangedFlag)
                    File.WriteAllText(fullPath, jsCode);
            }
            catch (HyperStatException)
            {
                Warning("The file `{0}` was not found", fullPath);
            }
            catch (Jurassic.JavaScriptException ex)
            {
                Warning("Script Compile Error: {0}: `{1}`", ex.Message, fullPath);
            }
            catch (Exception)
            {
                Warning("The file `{0}` could not be processed", fullPath);
            }
        }

        /// <summary>
        /// Compiles the given stylus code into CSS.
        /// </summary>
        /// <param name="code">The Stylus code to be compiled.</param>
        /// <returns>Compiled CSS code.</returns>
        private string CompileStylus(string code)
        {
            // TODO: Get Stylus compilation to work
            // (See Jurassic Bug #29996: http://jurassic.codeplex.com/workitem/29996)
            throw new NotImplementedException("Stylus is not yet supported.");
            
            if (stylEngine == null)
            {
                // Init the JS engine for Stylus
                string stylCompiler = Util.ResourceAsString("stylus.js");
                if (stylCompiler == null)
                {
                    Warning("There was a problem while loading the Stylus compiler.");
                    return code;
                }
                stylEngine = new Jurassic.ScriptEngine();
                stylEngine.Evaluate(stylCompiler);
            }

            // Compile
            return stylEngine.CallGlobalFunction<string>("compilify_stylus", code);
        }

        /// <summary>
        /// Adds the language tags from the language comments to pre elements for Prettify.
        /// </summary>
        /// <param name="pageDoc"></param>
        private void AddPrettifyLangs(HtmlDocument pageDoc)
        {
            HtmlNodeCollection commentNodes = pageDoc.DocumentNode.SelectNodes("//comment()");
            if (commentNodes == null)
                return;
            foreach (HtmlNode comment in commentNodes)
            {
                if (comment.InnerText.Contains("language: ", StringComparison.OrdinalIgnoreCase))
                {
                    string[] parts = comment.InnerText.Split(':');
                    if (parts.Length > 1)
                    {
                        // Get the lang-cs thingy
                        parts[1] = Regex.Replace(parts[1], "lang|<|!|-|>", string.Empty).Trim();

                        // Find the code block to apply to
                        if (comment.ParentNode.ChildNodes.Count > 1)
                        {
                            foreach (HtmlNode curNode in comment.ParentNode.ChildNodes)
                            {
                                if (curNode.NodeType == HtmlNodeType.Element &&
                                    curNode.Name.Equals("pre", StringComparison.OrdinalIgnoreCase))
                                {
                                    // Apply the class to the pre
                                    string classes = curNode.GetAttributeValue("class", string.Empty) + " lang-" + parts[1];
                                    curNode.SetAttributeValue("class", classes.Trim());

                                    // Try to apply the class to any code nodes in the pre
                                   if (curNode.ChildNodes.Count > 0 &&
                                       curNode.ChildNodes[0].NodeType == HtmlNodeType.Element &&
                                       curNode.ChildNodes[0].Name.Equals("code", StringComparison.OrdinalIgnoreCase))
                                       curNode.ChildNodes[0].SetAttributeValue("class", (curNode.ChildNodes[0].GetAttributeValue("class", string.Empty) + " language-" + parts[1]).Trim());

                                    // Remove the comment
                                    comment.Remove();
                                    break;
                                }
                            }
                        }
                    }
                }
            }
        }

        /// <summary>
        /// Copies required files to project output.
        /// </summary>
        private void CopyFiles(IEnumerable list)
        {
            string baseUri = Util.Pathify(ProjectDir.TrimEnd('\\').TrimEnd('/') + Path.DirectorySeparatorChar);

            foreach (string item in list)
            {
                try
                {
                    Console.WriteLine("Copying " + item);

                    string srcPath = Path.Combine(ProjectDir, item);
                    string dstPath = Path.Combine(OutputDir, item);

                    // Work out if the path is a file or a directory
                    if ((File.GetAttributes(srcPath) & FileAttributes.Directory) == FileAttributes.Directory)
                    {
                        // Directory

                        // Create all of the directories
                        foreach (string dirPath in Directory.GetDirectories(srcPath, "*", SearchOption.AllDirectories))
                            Directory.CreateDirectory(dirPath.Replace(srcPath, dstPath));

                        // Copy all the files
                        foreach (string newPath in Directory.GetFiles(srcPath, "*.*", SearchOption.AllDirectories))
                        {
                            // Monitor file changes
                            string relUri = MakeRelativePath(baseUri, newPath);
                            MonitorMiscFile(relUri);

                            // We know this file
                            //AddKnownFile(newPath);

                            Directory.CreateDirectory(Path.GetDirectoryName(newPath.Replace(srcPath, dstPath)));
                            CopyFile(newPath, newPath.Replace(srcPath, dstPath), true); // overwrite if exists
                        }
                    }
                    else
                    {
                        // Monitor file changes
                        string relUri = MakeRelativePath(baseUri, srcPath);
                        MonitorMiscFile(relUri);

                        // We know this file
                        //AddKnownFile(dstPath);

                        // Copy single file
                        Directory.CreateDirectory(Path.GetDirectoryName(dstPath));
                        CopyFile(srcPath, dstPath, true);
                    }
                }
                catch (Exception)
                {
                    // Copy problem
                    Warning("The item `{0}` could not be properly copied to the output", item);
                }
            }
        }

        /// <summary>
        /// Copies the given file, creating any directories as required.
        /// </summary>
        private void CopyFile(string fullSrcPath, string fullDstPath, bool overwrite = true)
        {
            try
            {
                if (!File.Exists(fullSrcPath))
                    throw new HyperStatException("The file " + fullSrcPath + " could not be found.");
                Directory.CreateDirectory(Path.GetDirectoryName(fullDstPath));

                // We know this file
                AddKnownFile(fullDstPath);

                File.Copy(fullSrcPath, fullDstPath, overwrite);
            }
            catch (HyperStatException)
            {
                Warning("The file `{0}` was not found", fullSrcPath);
            }
            catch (Exception)
            {
                Warning("The file `{0}` could not be copied", fullSrcPath);
            }
        }

        /// <summary>
        /// Creates a relative path from one file or folder to another.
        /// </summary>
        /// <param name="fromPath">Contains the directory that defines the start of the relative path.</param>
        /// <param name="toPath">Contains the path that defines the endpoint of the relative path.</param>
        /// <returns>The relative path from the start directory to the end path.</returns>
        /// <exception cref="ArgumentNullException"></exception>
        public static String MakeRelativePath(String fromPath, String toPath)
        {
            if (String.IsNullOrEmpty(fromPath)) throw new ArgumentNullException("fromPath");
            if (String.IsNullOrEmpty(toPath)) throw new ArgumentNullException("toPath");

            Uri fromUri = new Uri(fromPath);
            Uri toUri = new Uri(toPath);

            Uri relativeUri = fromUri.MakeRelativeUri(toUri);
            String relativePath = Uri.UnescapeDataString(relativeUri.ToString());

            return relativePath;
        }

        /// <summary>
        /// Prints the given warning to the console.
        /// </summary>
        /// <param name="print">The warning to print.</param>
        /// <param name="args">Args for String.Format.</param>
        private void Warning(string print, params object[] args)
        {
            Console.WriteLine("--------------------------------------------");
            Console.WriteLine(print.ToUpper(), args);
            Console.WriteLine("--------------------------------------------");
        }

        /// <summary>
        /// Used to prevent infinite recursion.
        /// </summary>
        private HashSet<string> processedIncludes = new HashSet<string>();

        /// <summary>
        /// Recursively finds all the used includes in the given string.
        /// </summary>
        /// <param name="input">The string to traverse.</param>
        /// <returns>A list of the include IDs used (non-content includes only).</returns>
        private List<string> FindAllUsedIncludes(string input)
        {
            Regex inpl = new Regex(INCLUDE_REGEXP, RegexOptions.Compiled);
            MatchCollection matches = inpl.Matches(input);
            HashSet<string> output = new HashSet<string>();
         
            // Detect infinite recursion
            if (processedIncludes.Contains(input))
                return output.ToList();
            else
                processedIncludes.Add(input); // this has already been processed...

            foreach(Match match in matches)
            {
                if (match.Groups.Count != 2)
                    continue; // missing a required match group

                // Content include ID
                string ctntID = match.Groups[1].Value.Trim();
                
                // Try to find a path for the given content ID
                try
                {
                    string fPath = settings.MakeCtntInPath(ctntID);
                    output.Add(Path.GetFileNameWithoutExtension(fPath));
                    output.UnionWith(FindAllUsedIncludes(File.ReadAllText(fPath)));
                }
                catch
                {
                    continue; // invalid include
                }
            }

            return output.ToList();
        }

        /// <summary>
        /// Used to prevent more than NUM_ALLOWABLE_INCLUDE_LOOPS iterations of infinite content include loops.
        /// This prevents a StackOverflowException.
        /// </summary>
        private Dictionary<string, int> processedIncludesCount = new Dictionary<string, int>();

        /// <summary>
        /// Process content includes.
        /// </summary>
        /// <param name="input">The string to be processed.</param>
        /// <returns>The processed string.</returns>
        private string ProcessContentIncludes(string input)
        {
            if (processedIncludesCount.ContainsKey(input))
                if (processedIncludesCount[input] > NUM_ALLOWABLE_INCLUDE_LOOPS)
                {
                    Warning("WARNING: Infinite include loop detected and halted at stage {0}!", NUM_ALLOWABLE_INCLUDE_LOOPS);
                    return "...";
                }
                else
                    processedIncludesCount[input]++;
            else
                processedIncludesCount.Add(input, 0);

            Regex inpl = new Regex(INCLUDE_REGEXP, RegexOptions.Compiled);
            MatchEvaluator evaluator = new MatchEvaluator(EvalContentInc);
            return inpl.Replace(input, evaluator);
        }

        /// <summary>
        /// Processes content include matches (MatchEvaluator helper function)
        /// </summary>
        private string EvalContentInc(Match incl)
        {
            if (incl.Groups.Count != 2)
                return incl.Value; // missing a required match group

            // Get the content ID
            string ctntID = incl.Groups[1].Value.Trim();

            // Look in the cache first
            if (contentIncludeCache.ContainsKey(ctntID))
            {
                return contentIncludeCache[ctntID];
            }

            // Not in the cache, compile the include
            else
            {
                // Enumerate content files array from content directory
                DirectoryInfo ctntDir = new DirectoryInfo(Path.Combine(ProjectDir, CONTENT_DIR));
                Regex fileSearchPattern = new Regex(String.Format("^.*{0}.*$", ctntID), RegexOptions.IgnoreCase);
                FileInfo[] contentFiles = ctntDir.GetFiles(fileSearchPattern);

                // Loop matches
                foreach (FileInfo file in contentFiles)
                {
                    // Perfect match, read the file and return its contents
                    if (Path.GetFileNameWithoutExtension(file.FullName).Equals(ctntID, StringComparison.OrdinalIgnoreCase))
                    {
                        Console.WriteLine("     -- " + file.Name);
                        string output = File.ReadAllText(file.FullName);

                        // Monitor the include if it's not a main content file and isn't already monitored
                        if (!settings.Content.ContainsKey(Path.GetFileNameWithoutExtension(file.FullName)) && 
                            !monitoredIncludes.Contains(Path.GetFileNameWithoutExtension(file.FullName)))
                            monitoredIncludes.Add(Path.GetFileNameWithoutExtension(file.FullName));

                        // Process markdown files
                        string lowerExt = file.Extension.ToLower().TrimStart('.');
                        if (settings.ContentExts.ContainsKey(lowerExt) &&
                            settings.ContentExts[lowerExt] == HyperStatSettings.ContentType.Markdown)
                            output = markdown.Transform(output);

                        // Process any includes within this include (recursive)
                        output = ProcessContentIncludes(output);

                        if(!contentIncludeCache.ContainsKey(ctntID))
                            contentIncludeCache.Add(ctntID, output); // cache this include
                        return output;
                    }
                    continue;
                }

                return incl.Value;
            }
        }

        /// <summary>
        /// Runs hooks for the given hook location.
        /// </summary>
        /// <param name="location">The hook location to run.</param>
        private void RunHooks(HyperStatSettings.HookLocation location)
        {
            foreach (string hookFile in settings.Hooks.Keys)
            {
                if (settings.Hooks[hookFile] == location)
                {
                    // Do not allow paths
                    if (hookFile.Contains('/') || hookFile.Contains('\\'))
                        continue;

                    Console.WriteLine("Hook: " + hookFile);

                    string fullPath;

                    // Determine whether there is an extension in the path, and use it if so
                    if(hookFile.Contains('.'))
                    {
                        fullPath = Path.Combine(ProjectDir, hookFile);
                    }
                    else
                    {
                        // Guess the path based on OS, or use exact path if file exists
                        if (File.Exists(Path.Combine(ProjectDir, hookFile)))
                            fullPath = Path.Combine(ProjectDir, hookFile);
                        else if (Program.IsLinux)
                            fullPath = Path.Combine(ProjectDir, hookFile + ".sh");
                        else
                            fullPath = Path.Combine(ProjectDir, hookFile + ".bat");
                    }

                    // Try to run the script
                    ProcessStartInfo scriptStart = new ProcessStartInfo()
                    {
                        CreateNoWindow = true,
                        UseShellExecute = false,
                        WindowStyle = ProcessWindowStyle.Hidden,
                        RedirectStandardOutput = true,
                        RedirectStandardError = true,
                        WorkingDirectory = ProjectDir
                    };

                    if (Program.IsLinux && fullPath.EndsWith(".sh", StringComparison.OrdinalIgnoreCase))
                    {
                        // nix shell script
                        scriptStart.FileName = "/bin/sh";
                        scriptStart.Arguments = "\"" + fullPath + "\"";
                    }
                    else
                    {
                        // Windows (or normal executable under Linux)
                        scriptStart.FileName = fullPath;
                    }
                    Process proc = Process.Start(scriptStart);
                    proc.WaitForExit();
                    Program.Debug(proc.StandardError.ReadToEnd());
                    Program.Debug("Hook `{0}` output:" + proc.StandardOutput.ReadToEnd(), hookFile);
                }
            }
        }

        /// <summary>
        /// Deletes un-needed files from the output directory.
        /// </summary>
        /// <param name="newKnownFiles">Known files (full paths), from a recent .changes.hyst compilation.</param>
        /// <param name="oldKnownFiles">Known files (full paths), from the last .changes.hyst compilation.</param>
        private void CleanOutputDir(List<string> newKnownFiles, List<string> oldKnownFiles)
        {
            try
            {
                Console.WriteLine("Cleaning output directory...");

                // Stop if either of the known files lists are empty (this indicates a possible problem)
                if (newKnownFiles.Count == 0 || 
                    (oldKnownFiles != null && oldKnownFiles.Count == 0))
                    return;

                DirectoryInfo outPutDir = new DirectoryInfo(OutputDir);
                DirectoryInfo[] outDirs = outPutDir.GetDirectories("*", SearchOption.AllDirectories);
                FileInfo[] outFiles = outPutDir.GetFiles("*", SearchOption.AllDirectories);

                List<FileInfo> unknowns = new List<FileInfo>();

                // Use old and new if we have both
                if (oldKnownFiles != null)
                {
                    foreach (FileInfo file in outFiles)
                    {
                        // Delete anything that was known and isn't now
                        if (oldKnownFiles.Contains(file.FullName) &&
                            !newKnownFiles.Contains(file.FullName))
                            file.Delete();

                        // Add completely unknown files to unknowns list
                        else if (!oldKnownFiles.Contains(file.FullName) &&
                                !newKnownFiles.Contains(file.FullName))
                            unknowns.Add(file);
                    }
                }

                // Use only new if we don't have old
                else
                {
                    // Add unknowns to unknowns list
                    foreach (FileInfo file in outFiles)
                        if (!newKnownFiles.Contains(file.FullName))
                            unknowns.Add(file);
                }

                // Prompt to delete unknown files
                if (unknowns.Count > 0)
                {
                    Console.WriteLine("\r\n=============================================\r\n");

                    Console.WriteLine("The following unknown files were found in the output directory:\r\n");

                    foreach (FileInfo unknown in unknowns)
                        Console.WriteLine("  {0}", MakeRelativePath(Util.Slashify(OutputDir), unknown.FullName));

                    Console.WriteLine("\r\n=============================================\r\n");

                    Console.Write("Do you wish to remove these files? [y/N]  ");

                    if (Console.ReadKey().Key == ConsoleKey.Y)
                    {
                        foreach (FileInfo file in unknowns)
                            file.Delete();
                    }

                    Console.WriteLine("\r\n");
                }

                // Delete empty directories
                foreach (DirectoryInfo outDir in outDirs)
                {
                    if (outDir.GetFiles().Length == 0 && outDir.GetDirectories().Length == 0)
                        outDir.Delete();
                }
            }
            catch
            {
                Program.Debug("The output directory could not be cleaned.");
            }
        }

        /// <summary>
        /// Adds files to known files list.
        /// </summary>
        private void AddKnownFile(string path)
        {
            if (!knownOutputFiles.Contains(path))
                knownOutputFiles.Add(path);
        }

        /// <summary>
        /// Writes the known files to .knownfiles.hyst.
        /// </summary>
        private void WriteKnownFiles()
        {
            try
            {
                string knownFilesPath = Path.Combine(ProjectDir, KNOWN_FILES);
                string[] outLines = knownOutputFiles.ToArray();
                for (int i = 0; i < outLines.Length; i++)
                    outLines[i] = MakeRelativePath(Util.Slashify(OutputDir), outLines[i]);
                File.WriteAllLines(knownFilesPath, outLines);
            }
            catch
            {
                Program.Debug("There was a problem while writing {0}", KNOWN_FILES);
            }
        }

        /// <summary>
        /// Reads the known files file.
        /// </summary>
        /// <returns>The list of known files, or null on error.</returns>
        private List<string> ReadKnownFiles()
        {
            try
            {
                string knownFilesPath = Path.Combine(ProjectDir, KNOWN_FILES);
                List<string> output = File.ReadAllLines(knownFilesPath).ToList();
                for (int i = 0; i < output.Count; i++)
                    output[i] = Path.Combine(OutputDir, Util.Pathify(output[i]));
                return output;
            }
            catch
            {
                Program.Debug("There was a problem while reading {0}", KNOWN_FILES);
                return null;
            }
        }

        /// <summary>
        /// Compiles the given CoffeeScript code.
        /// </summary>
        /// <param name="code">The CoffeeScript code to be compiled.</param>
        /// <returns>JavaScript result.</returns>
        public string CompileCoffeeScript(string code)
        {
            if (csEngine == null)
            {
                // Init the Jurassic engine for CoffeeScript
                string csCompiler = Util.ResourceAsString("coffee-script.js");
                if (csCompiler == null)
                {
                    Warning("There was a problem while attempting to load the CoffeeScript compiler.");
                    return code;
                }
                csEngine = new ScriptEngine();
                csEngine.Evaluate(csCompiler);
            }
            
            // Compile
            return csEngine.CallGlobalFunction<string>("compilify_cs", code, true);
        }

        /// <summary>
        /// Represents a CSS type.
        /// </summary>
        private enum CssType
        {
            LESS,
            CSS,
            Stylus
        }

        /// <summary>
        /// Represents a script type.
        /// </summary>
        private enum ScriptType
        {
            CoffeeScript,
            JavaScript
        }

        /// <summary>
        /// Override for LESS compilation logging.
        /// </summary>
        private class LessLogger : Logger
        {
            private StaticHtmlGenerator gen;

            public LessLogger(LogLevel level, StaticHtmlGenerator gen) : base(level)
            {
                this.gen = gen;
            }

            protected override void Log(string message)
            {
                gen.Warning("LESS Compilation: {0}", message);
            }
        }
    }

    /// <summary>
    /// Extension methods.
    /// </summary>
    public static class MethodExtensions
    {
        /// <summary>
        /// Returns a file list from the current directory.
        /// </summary>
        /// <param name="pattern">The pattern to match against filenames.</param>
        public static FileInfo[] GetFiles(this DirectoryInfo dirInfo, Regex pattern)
        {
            List<FileInfo> output = new List<FileInfo>();
            FileInfo[] files = dirInfo.GetFiles("*", SearchOption.AllDirectories);

            foreach(FileInfo info in files)
                if (pattern.IsMatch(info.Name))
                    output.Add(info);

            return output.ToArray();
        }

        /// <summary>
        /// String.Replace with comparison type.
        /// </summary>
        public static string Replace(this string originalString, string oldValue, string newValue, StringComparison comparison)
        {
            StringBuilder sb = new StringBuilder();

            int previousIndex = 0;
            int index = originalString.IndexOf(oldValue, comparison);
            while (index != -1)
            {
                sb.Append(originalString.Substring(previousIndex, index - previousIndex));
                sb.Append(newValue);
                index += oldValue.Length;

                previousIndex = index;
                index = originalString.IndexOf(oldValue, index, comparison);
            }
            sb.Append(originalString.Substring(previousIndex));

            return sb.ToString();
        }

        /// <summary>
        /// Determines if the given string contains the given substring, using the given StringComparison technique.
        /// </summary>
        public static bool Contains(this string source, string toCheck, StringComparison comp)
        {
            return source.IndexOf(toCheck, comp) >= 0;
        }

        /// <summary>
        /// Determines if the string starts with any of the given check strings.
        /// </summary>
        /// <param name="toCheck">The strings to check.</param>
        /// <returns>True if it starts with one of the check strings, false otherwise.</returns>
        public static bool StartsWith(this string source, StringComparison comp, params string[] toCheck)
        {
            foreach (string check in toCheck)
                if (source.StartsWith(check, comp))
                    return true;
            return false;
        }
    }
}
